Files
mes-budgets-participatifs/src/app/campaigns/[id]/vote/[participantId]/page.tsx
2025-09-16 13:41:32 +02:00

574 lines
23 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
import { MarkdownContent } from '@/components/MarkdownContent';
import { PROJECT_CONFIG } from '@/lib/project.config';
import Footer from '@/components/Footer';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function PublicVotePage() {
const params = useParams();
const router = useRouter();
const campaignId = params.id as string;
const participantId = params.participantId as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [participant, setParticipant] = useState<Participant | null>(null);
const [propositions, setPropositions] = useState<PropositionWithVote[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Votes temporaires stockés localement
const [localVotes, setLocalVotes] = useState<Record<string, number>>({});
const [totalVoted, setTotalVoted] = useState(0);
const [isRandomOrder, setIsRandomOrder] = useState(false);
const [isCompactView, setIsCompactView] = useState(false);
const [currentVisibleProposition, setCurrentVisibleProposition] = useState(1);
const [isOverBudget, setIsOverBudget] = useState(false);
useEffect(() => {
if (campaignId && participantId) {
loadData();
}
}, [campaignId, participantId]);
// Écouter les changements de connectivité réseau
useEffect(() => {
const handleOnline = () => {
console.log('Connexion réseau rétablie');
setError('');
};
const handleOffline = () => {
console.log('Connexion réseau perdue');
setError('Connexion réseau perdue. Veuillez vérifier votre connexion internet.');
};
if (typeof window !== 'undefined') {
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
}
return () => {
if (typeof window !== 'undefined') {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
}
};
}, []);
// Calculer le total voté à partir des votes locaux
useEffect(() => {
const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0);
setTotalVoted(total);
// Vérifier si on dépasse le budget
if (campaign && total > campaign.budget_per_user) {
setIsOverBudget(true);
// Arrêter la vibration après 1 seconde
setTimeout(() => setIsOverBudget(false), 1000);
} else {
setIsOverBudget(false);
}
}, [localVotes, campaign]);
// Observer les propositions visibles
useEffect(() => {
if (propositions.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
let highestVisibleIndex = 1;
entries.forEach((entry) => {
if (entry.isIntersecting) {
const propositionIndex = parseInt(entry.target.getAttribute('data-proposition-index') || '1');
if (propositionIndex > highestVisibleIndex) {
highestVisibleIndex = propositionIndex;
}
}
});
if (highestVisibleIndex > 1) {
setCurrentVisibleProposition(highestVisibleIndex);
}
},
{
threshold: 0.3, // La proposition doit être visible à 30% pour être considérée comme active
rootMargin: '-10% 0px -10% 0px' // Zone de détection réduite
}
);
// Attendre que le DOM soit mis à jour
setTimeout(() => {
const propositionElements = document.querySelectorAll('[data-proposition-index]');
propositionElements.forEach((element) => observer.observe(element));
}, 100);
return () => observer.disconnect();
}, [propositions, isCompactView]);
const loadData = async () => {
try {
setLoading(true);
setError('');
// Vérifier la connectivité réseau
if (typeof window !== 'undefined' && !navigator.onLine) {
throw new Error('Pas de connexion internet. Veuillez vérifier votre connexion réseau.');
}
const [campaignData, participants, propositionsData] = await Promise.all([
campaignService.getById(campaignId),
participantService.getByCampaign(campaignId),
propositionService.getByCampaign(campaignId)
]);
const participantData = participants.find(p => p.id === participantId);
if (!campaignData) {
setError('Campagne non trouvée');
return;
}
if (!participantData) {
setError('Participant non trouvé');
return;
}
if (campaignData.status !== 'voting') {
setError('Cette campagne n\'est pas en phase de vote');
return;
}
setCampaign(campaignData);
setParticipant(participantData);
// Charger les votes existants
const votes = await voteService.getByParticipant(campaignId, participantId);
// Combiner les propositions avec leurs votes
let propositionsWithVotes = propositionsData.map(proposition => ({
...proposition,
vote: votes.find(vote => vote.proposition_id === proposition.id)
}));
// Vérifier si l'ordre aléatoire est activé
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', true);
if (randomizePropositions) {
// Mélanger les propositions de manière aléatoire
propositionsWithVotes = propositionsWithVotes.sort(() => Math.random() - 0.5);
setIsRandomOrder(true);
}
setPropositions(propositionsWithVotes);
// Initialiser les votes locaux avec les votes existants
const initialVotes: Record<string, number> = {};
votes.forEach(vote => {
initialVotes[vote.proposition_id] = vote.amount;
});
setLocalVotes(initialVotes);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
let errorMessage = 'Erreur lors du chargement des données';
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'object' && error !== null) {
// Essayer d'extraire plus d'informations de l'erreur
const errorObj = error as any;
if (errorObj.message) {
errorMessage = errorObj.message;
} else if (errorObj.error) {
errorMessage = errorObj.error;
} else if (errorObj.details) {
errorMessage = errorObj.details;
}
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleVoteChange = (propositionId: string, amount: number) => {
if (amount === 0) {
// Si on sélectionne "Aucun vote", on supprime le vote local
const newLocalVotes = { ...localVotes };
delete newLocalVotes[propositionId];
setLocalVotes(newLocalVotes);
} else {
// Sinon on met à jour le vote local
setLocalVotes(prev => ({
...prev,
[propositionId]: amount
}));
}
};
const handleSubmit = async () => {
if (totalVoted !== campaign?.budget_per_user) {
setError(`Vous devez dépenser exactement ${campaign?.budget_per_user}`);
return;
}
setSaving(true);
setError('');
try {
// Préparer les votes à sauvegarder (seulement ceux avec amount > 0)
const votesToSave = Object.entries(localVotes)
.filter(([_, amount]) => amount > 0)
.map(([propositionId, amount]) => ({
proposition_id: propositionId,
amount
}));
// Utiliser la méthode atomique pour remplacer tous les votes
await voteService.replaceVotes(campaignId, participantId, votesToSave);
setSuccess(true);
} catch (error) {
console.error('Erreur lors de la validation:', error);
// Améliorer l'affichage de l'erreur
let errorMessage = 'Erreur lors de la validation des votes';
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'object' && error !== null) {
// Essayer d'extraire plus d'informations de l'erreur
const errorObj = error as any;
if (errorObj.message) {
errorMessage = errorObj.message;
} else if (errorObj.error) {
errorMessage = errorObj.error;
} else if (errorObj.details) {
errorMessage = errorObj.details;
}
}
setError(errorMessage);
} finally {
setSaving(false);
}
};
const getSpendingTiers = () => {
if (!campaign) return [];
return campaign.spending_tiers.split(',').map(tier => parseInt(tier.trim())).filter(tier => tier > 0);
};
const getVoteStatus = () => {
if (!campaign) return { status: 'error', message: 'Campagne non trouvée' };
const remaining = campaign.budget_per_user - totalVoted;
if (remaining === 0) {
return { status: 'success', message: 'Budget complet ! Vous pouvez valider votre vote.' };
} else if (remaining > 0) {
return { status: 'warning', message: `Il vous reste ${remaining}€ à dépenser` };
} else {
return { status: 'error', message: `Vous avez dépensé ${Math.abs(remaining)}€ de trop` };
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement de la page de vote...</p>
</div>
</div>
);
}
if (error && !campaign) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Erreur</h2>
<p className="mt-2 text-sm text-gray-600">{error}</p>
<Link
href="/"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à l'accueil
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Vote enregistré !</h2>
<p className="mt-2 text-sm text-gray-600">
Votre vote a été enregistré avec succès. Vous pouvez revenir modifier vos choix tant que la campagne est en cours.
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à mon vote
</button>
</div>
</div>
</div>
);
}
const voteStatus = getVoteStatus();
const spendingTiers = getSpendingTiers();
return (
<div className="min-h-screen bg-gray-50 vote-page">
{/* Header fixe avec le total et le bouton de validation */}
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div>
<h1 className="text-lg font-semibold text-gray-900">{campaign?.title}</h1>
<p className="text-lg font-bold text-indigo-600">
{participant?.first_name} {participant?.last_name}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className={`text-2xl font-bold transition-all duration-300 ${
isOverBudget
? 'text-red-600 animate-pulse'
: totalVoted === campaign?.budget_per_user
? 'text-green-600 scale-105'
: totalVoted > 0
? 'text-indigo-600'
: 'text-gray-900'
} ${isOverBudget ? 'animate-bounce' : ''}`}>
{totalVoted}€ / {campaign?.budget_per_user}€
</div>
<div className={`text-sm font-medium transition-colors duration-300 ${
voteStatus.status === 'success' ? 'text-green-600' :
voteStatus.status === 'warning' ? 'text-yellow-600' :
'text-red-600'
}`}>
{voteStatus.message}
</div>
</div>
<button
onClick={handleSubmit}
disabled={saving || totalVoted !== campaign?.budget_per_user}
className={`px-6 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
totalVoted === campaign?.budget_per_user
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
{saving ? 'Enregistrement...' : 'Valider mon vote'}
</button>
</div>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-20">
{/* Informations de la campagne */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
<div>
<MarkdownContent
content={campaign?.description || ''}
className="mt-1 text-base font-medium text-gray-900"
/>
</div>
</div>
</div>
{/* Message discret sur l'ordre aléatoire */}
{isRandomOrder && (
<div className="mb-6 text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs text-gray-400 bg-gray-50 border border-gray-100">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation
</span>
</div>
)}
{/* Propositions */}
{propositions.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune proposition</h3>
<p className="mt-1 text-sm text-gray-500">Aucune proposition n'a é soumise pour cette campagne.</p>
</div>
) : (
<div className={`${isCompactView ? 'space-y-3' : 'space-y-6'}`}>
{propositions.map((proposition, index) => (
<div
key={proposition.id}
data-proposition-index={index + 1}
className={`rounded-lg shadow-sm border overflow-hidden transition-all duration-200 relative ${
localVotes[proposition.id] && localVotes[proposition.id] > 0
? 'border-indigo-400 shadow-lg bg-indigo-100'
: 'bg-white border-gray-200'
}`}
>
{!isCompactView && (
<div className="absolute -top-1 left-4 bg-white px-2 text-xs text-gray-500 font-medium z-10 border border-gray-200 rounded-t">
Proposition
</div>
)}
<div className={`${isCompactView ? 'p-4' : 'p-6'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className={`font-medium text-gray-900 ${isCompactView ? 'text-base mb-1' : 'text-lg mb-2'}`}>
{proposition.title}
</h3>
{!isCompactView && (
<MarkdownContent
content={proposition.description}
className="text-sm text-gray-600 mb-4"
/>
)}
</div>
</div>
<div className={isCompactView ? 'mt-3' : 'mt-6'}>
{!isCompactView && (
<label className="block text-sm font-medium text-gray-700 mb-3">
Pour cette proposition, vous investissez :
</label>
)}
<div className="space-y-4">
{/* Slider */}
<div className="relative">
<input
type="range"
min="0"
max={spendingTiers.length}
step="1"
value={localVotes[proposition.id] ? (localVotes[proposition.id] === 0 ? 0 : spendingTiers.indexOf(localVotes[proposition.id]) + 1) : 0}
onChange={(e) => {
const index = parseInt(e.target.value);
const amount = index === 0 ? 0 : spendingTiers[index - 1];
handleVoteChange(proposition.id, amount);
}}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/>
{/* Marqueurs des paliers */}
<div className="relative mt-3 mb-16" style={{ marginLeft: '12px', marginRight: '24px' }}>
{/* Marqueur 0€ */}
<div className="absolute text-center" style={{ left: '0%', transform: 'translateX(-12px)' }}>
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2"></div>
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">0</span>
</div>
{/* Marqueurs des paliers */}
{spendingTiers.map((tier, index) => {
// Calcul correct de la position pour correspondre au slider
const position = ((index + 1) / spendingTiers.length) * 100;
return (
<div
key={`tier-${index}-${tier}`}
className="absolute text-center"
style={{
left: `${position}%`,
transform: 'translateX(-12px)'
}}
>
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2"></div>
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">{tier}</span>
</div>
);
})}
</div>
</div>
{/* Valeur sélectionnée */}
{(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
<div className="text-center mt-12">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800">
Vote sélectionné : {localVotes[proposition.id]}
</span>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{error && (
<div className="mt-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{/* Footer discret */}
<Footer />
</div>
{/* Barre fixe en bas */}
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white shadow-lg border-t border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Proposition {currentVisibleProposition} / {propositions.length}
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500">
<span>Juste les titres</span>
<button
onClick={() => setIsCompactView(!isCompactView)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
!isCompactView ? 'bg-indigo-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
!isCompactView ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
<span>Avec descriptions</span>
</div>
</div>
</div>
</div>
</div>
);
}